package com.github.eclipseace.mybatis.pagination;

import java.sql.SQLException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;

import org.apache.ibatis.builder.StaticSqlSource;
import org.apache.ibatis.executor.Executor;
import org.apache.ibatis.mapping.BoundSql;
import org.apache.ibatis.mapping.MappedStatement;
import org.apache.ibatis.mapping.ResultMap;
import org.apache.ibatis.mapping.ResultMapping;
import org.apache.ibatis.mapping.SqlCommandType;
import org.apache.ibatis.mapping.SqlSource;
import org.apache.ibatis.plugin.Interceptor;
import org.apache.ibatis.plugin.Intercepts;
import org.apache.ibatis.plugin.Invocation;
import org.apache.ibatis.plugin.Plugin;
import org.apache.ibatis.plugin.PluginException;
import org.apache.ibatis.plugin.Signature;
import org.apache.ibatis.session.Configuration;
import org.apache.ibatis.session.ResultHandler;
import org.apache.ibatis.session.RowBounds;

import com.github.eclipseace.mybatis.pagination.dialect.PaginationDialect;
import com.github.eclipseace.mybatis.pagination.dialect.PaginationDialectType;
import com.github.eclipseace.mybatis.util.Assert;
import com.github.eclipseace.mybatis.util.ReflectionUtils;
import com.github.eclipseace.mybatis.util.BuilderUtils;
import com.github.eclipseace.mybatis.util.StringUtils;

/**
 * @author eclipseAce
 */
@Intercepts({
		@Signature(type = Executor.class, method = "query", args = {
				MappedStatement.class, Object.class, RowBounds.class, ResultHandler.class })
})
public class PaginationInterceptor implements Interceptor {
	public static final String DEFAULT_DATABASE_ID = "default";

	public static final String PAGE_STATEMENT_ID_SUFFIX = "!pageQuery";
	public static final String TOTAL_STATEMENT_ID_SUFFIX = "!totalQuery";

	private final Map<String, PaginationDialect> dialects = new HashMap<>();

	public PaginationInterceptor() {
	}

	public PaginationInterceptor(String defaultDialectClassName) {
		setDefaultDialect(defaultDialectClassName);
	}

	public PaginationInterceptor(Class<? extends PaginationDialect> dialectClass) {
		setDefaultDialect(dialectClass);
	}

	public PaginationInterceptor(PaginationDialectType defaultDialect) {
		setDefaultDialect(defaultDialect);
	}

	public PaginationInterceptor(PaginationDialect defaultDialect) {
		setDefaultDialect(defaultDialect);
	}

	protected PaginationDialect getDialect(String databaseId) {
		String key = StringUtils.defaultIfEmpty(databaseId, DEFAULT_DATABASE_ID);
		PaginationDialect dialect = dialects.get(key);
		Assert.isNotNull(dialect, "no corresponding dialect for database id [" + key + "]");
		return dialect;
	}

	protected MappedStatement getTotalStatement(MappedStatement statement, Object parameter) {
		Configuration configuration = statement.getConfiguration();
		String statementId = statement.getId() + TOTAL_STATEMENT_ID_SUFFIX;
		MappedStatement totalStatement;
		if (configuration.hasStatement(statementId)) {
			totalStatement = BuilderUtils.buildFrom(configuration.getMappedStatement(statementId), statementId)
					.build();
		} else {
			PaginationDialect dialect = getDialect(statement.getDatabaseId());
			BoundSql boundSql = statement.getBoundSql(parameter);
			String totalSql = dialect.wrapTotalQuery(boundSql.getSql());
			SqlSource sqlSource = new StaticSqlSource(configuration, totalSql, boundSql.getParameterMappings());
			String resultMapId = statementId + "-Inline";
			List<ResultMap> resultMaps = Collections.singletonList(
					new ResultMap.Builder(configuration, resultMapId, Long.class, new ArrayList<ResultMapping>())
							.build());
			totalStatement = BuilderUtils.buildFrom(statement, statementId, sqlSource, SqlCommandType.SELECT)
					.resultMaps(resultMaps)
					.build();
		}
		return totalStatement;
	}

	protected MappedStatement getPageStatement(MappedStatement statement, Object parameter,
			final RowBounds rowBounds) {
		Configuration configuration = statement.getConfiguration();
		String statementId = statement.getId() + PAGE_STATEMENT_ID_SUFFIX;
		MappedStatement pageStatement;
		if (configuration.hasStatement(statementId)) {
			final MappedStatement originalStatement = configuration.getMappedStatement(statementId);
			SqlSource sqlSource = new SqlSource() {
				@Override
				public BoundSql getBoundSql(Object parameterObject) {
					BoundSql boundSql = originalStatement.getSqlSource().getBoundSql(parameterObject);
					boundSql.setAdditionalParameter("rowBounds", rowBounds);
					return boundSql;
				}
			};
			pageStatement = BuilderUtils.buildFrom(originalStatement, statementId, sqlSource)
					.build();
		} else {
			PaginationDialect dialect = getDialect(statement.getDatabaseId());
			BoundSql boundSql = statement.getBoundSql(parameter);
			String pageSql = dialect.wrapPageQuery(boundSql.getSql(), rowBounds);
			SqlSource sqlSource = new StaticSqlSource(configuration, pageSql, boundSql.getParameterMappings());
			pageStatement = BuilderUtils.buildFrom(statement, statementId, sqlSource, SqlCommandType.SELECT)
					.build();
		}
		return pageStatement;
	}

	protected long executeTotalQuery(Executor executor, MappedStatement statement, Object parameter)
			throws SQLException {
		MappedStatement totalStatement = getTotalStatement(statement, parameter);
		List<Object> totalResults = executor.query(totalStatement, parameter, RowBounds.DEFAULT,
				Executor.NO_RESULT_HANDLER);
		Assert.isNotEmpty(totalResults, "total query returns no data");
		Object result = totalResults.get(0);
		Assert.isInstanceOf(result, Number.class, "total query returns NaN");
		return ((Number) result).longValue();
	}

	protected List<Object> executePageQuery(Executor executor, MappedStatement statement, Object parameter,
			RowBounds rowBounds) throws SQLException {
		MappedStatement pageStatement = getPageStatement(statement, parameter, rowBounds);
		return executor.query(pageStatement, parameter, RowBounds.DEFAULT, Executor.NO_RESULT_HANDLER);
	}

	@Override
	public Object intercept(Invocation invocation) throws Throwable {
		Object target = invocation.getTarget();
		Object[] args = invocation.getArgs();
		MappedStatement statement = (MappedStatement) args[0];
		Object parameter = args[1];
		RowBounds rowBounds = (RowBounds) args[2];
		if (rowBounds instanceof PageRowBounds) {
			PageRowBounds pageRowBounds = (PageRowBounds) rowBounds;
			Executor executor = (Executor) ReflectionUtils.unwrapPlugin(target);
			ArrayPageList<Object> pageList = new ArrayPageList<>();
			pageList.setPageIndex(pageRowBounds.getIndex());
			pageList.setPageSize(pageRowBounds.getSize());
			pageList.setTotal(executeTotalQuery(executor, statement, parameter));
			pageList.addAll(executePageQuery(executor, statement, parameter, pageRowBounds));
			return pageList;
		} else {
			return invocation.proceed();
		}
	}

	@Override
	public Object plugin(Object target) {
		if (target instanceof Executor) {
			return Plugin.wrap(target, this);
		}
		return target;
	}

	@Override
	public void setProperties(Properties properties) {
		for (String key : properties.stringPropertyNames()) {
			String value = properties.getProperty(key);
			PaginationDialectType dialectType = PaginationDialectType.fromString(value);
			if (dialectType != null) {
				addDialect(key, dialectType);
			} else {
				addDialect(key, value);
			}
		}
	}

	public void addDialect(String databaseId, String dialectClassName) {
		Assert.isNotEmpty(dialectClassName, "dialectClassName is null or empty");
		Class<? extends PaginationDialect> dialectClass;
		try {
			dialectClass = Class.forName(dialectClassName).asSubclass(PaginationDialect.class);
		} catch (ClassNotFoundException e) {
			throw new PluginException("dialect class [" + dialectClassName + "] was not found");
		} catch (ClassCastException e) {
			throw new PluginException("dialect class [" + dialectClassName + "] is not a ["
					+ PaginationDialect.class.getName() + "]");
		}
		addDialect(databaseId, dialectClass);
	}

	public void addDialect(String databaseId, PaginationDialectType dialectType) {
		Assert.isNotNull(dialectType, "dialectType is null");
		addDialect(databaseId, dialectType.getDialectClass());
	}

	public void addDialect(String databaseId, Class<? extends PaginationDialect> dialectClass) {
		Assert.isNotNull(dialectClass, "dialectClass is null");
		PaginationDialect dialect;
		try {
			dialect = dialectClass.newInstance();
		} catch (InstantiationException | IllegalAccessException e) {
			throw new PluginException("failed to instant dialect class [" + dialectClass.getName() + "]", e);
		}
		addDialect(databaseId, dialect);
	}

	public void addDialect(String databaseId, PaginationDialect dialect) {
		Assert.isNotEmpty(databaseId, "databaseId is null or empty");
		Assert.isNotNull(dialect, "dialect is null or empty");
		dialects.put(databaseId, dialect);
	}

	public void setDefaultDialect(String dialectClassName) {
		addDialect(DEFAULT_DATABASE_ID, dialectClassName);
	}

	public void setDefaultDialect(Class<? extends PaginationDialect> dialectClass) {
		addDialect(DEFAULT_DATABASE_ID, dialectClass);
	}

	public void setDefaultDialect(PaginationDialectType dialectType) {
		addDialect(DEFAULT_DATABASE_ID, dialectType);
	}

	public void setDefaultDialect(PaginationDialect dialect) {
		addDialect(DEFAULT_DATABASE_ID, dialect);
	}
}
